Shadow Door

Neverwinter Nights NPC Control Interface

Copyright 2003 by Phillip Saltzman and Robert Zubek, Northwestern University
shadow-door-dev@cs.northwestern.edu

Based on the NWN Extender (NWNX), copyright 2003 by Ingmar Stieger

Licensed under the GNU General Public License

 

1. Introduction

Shadow Door is an agent control interface for the game Neverwinter Nights - it lets external processes control NPCs in the game. Using Shadow Door, developers can write external AI programs, using arbitrary languages and arbitrary platforms, to play the game with human players.

Shadow Door consists of:

  1. A server extension that communicates with external processes through a TCP socket
  2. A set of NPC scripts that receive commands from the external process, perform them, and send back observations
  3. Sample Lisp API to connect to the game and control an agent

 

System requirements and licensing

Shadow Door has been tested under Windows 2000 and XP, and it should also work on Windows 98. It incurs almost no performance overhead, so any system capable of running Neverwinter Nights should be sufficient.

The program is based on Ingmar Stieger's excellent NWN Extender, and is released under the terms of the GNU General Public License - see the license file for details. It also uses Mathias Rauen's madCodeHookLib library, which can be used for free for non-commercial purposes.

 

Installation

Unpack the distribution into your Neverwinter Nights directory (e.g., C:\Program Files\Neverwinter Nights). This will do the following:

  1. Copy the NWNX2 Shadow Door executable, Shadow Door and madCodeLibHook libraries, and documentation into the main game directory
  2. Create a subfolder Shadow Door, which contains the Lisp API, and C++ DLL sources
  3. Add a sample module to the modules subfolder
  4. Add NPC scripts to the erf subfolder

 

2. Running our sample code: Eliza

Eliza is a classic AI system that attempts to mimic a conversation with a Rogerian psychoanalyst. We have included a simple Common Lisp re-implementation, which we're going to use to explain how to get the system running.

 

Getting Lisp

To run Eliza, you will need Allegro Common Lisp (ACL) from Franz - a non-commercial version is available for free at franz.com. Please take this time to download and install the program.

 

Execution

1. Let's start the game server. Run the NWNX2 Shadow Door executable - it will start both NWNX2 and the NWServer. You can exit NWNX2 - it provides some extra functionality that we won't need in this example. In the NWServer, under Module Name, pick Shadow Door Example Module and hit Load. The server status bar should change to: "Running, login at will".

2. The example module contains one character named Eliza that will be controlled by our Lisp-based AI. With ACL installed, enter the Shadow Door\LISP Sources folder and double-click on eliza-demo-allegro.lpr. This will start ACL and load the necessary files. You should see a prompt that looks like:

  cg-user(1):

Type the following two commands at the prompt, as seen below:

  cg-user(1): (in-package common-lisp-user)
#<The common-lisp-user package>


cl-user(2): (shadow-door-eliza-loop)
...

This will start the Eliza - Lisp will start printing dots to let you know it's running, and all of the functionality will now happen inside the game.

3. Log into the game. Start Neverwinter Nights. In the main menu choose Multiplayer, log in with your multiplayer account, then select Join LAN Game. Pick the Shadow Door game, pick your character, and you're in!

You'll find yourself in a small room with Eliza. Start talking to her and she will respond with new utterances based on what you said. Your likes, dislikes, hopes, and dreams are always good topics. Some hard-wired reactions: saying "hi" will result in a courteous bow, "I hate you" will provoke an attack, and saying "!quit" will shut down the AI.

 

3. Using the Lisp API

Okay, but how can you use the system with your own AI engine? Let's examine that, again using Allegro Common Lisp.

Setting up your program involves two things: the NPC and Lisp need to be configured to communicate through Shadow Door.

 

Setting up the NPC

Suppose you already have a game module with an NPC that you would like to be controlled externally. We will need to set up the NPC to be controllable through Shadow Door. Open your module in the Aurora Toolbox and:

  1. Go to File | Import, select shadowdoor.erf - this imports the script code
  2. Go to Edit | Module Properties, then the Events tab. Under OnModuleLoad select sd_on_load
  3. Create a new creature or select an existing NPC, then open its Properties dialog. Under the Scripts tab:

Save and quit - that's all we need inside the module!

 

Setting up and Using the Lisp API

All right, so now we're going to explore how to control an NPC from within Lisp. I'm assuming you have some Lisp familiarity at this point, but a step-by-step transcript is also provided.

The basic commands for Shadow Door are:

The NPC reports its observations of world events, which can be retrieved via (sd-read-sexp) as described above. The observations are simple lists of strings. The first element is the type of observation, and remaining ones contain additional information. The sd-utilities.lisp file also contains functions for recognizing received observations and extracting the extra info:


Example

Let's see how to use these functions in vivo. The following is an annotated transcript of using Shadow Door interactively from Lisp.

First, we start NWNX2 Shadow Door and load the Eliza module as before; then start the game and join the module so that we're again in the same room as Eliza. Now we switch back to Windows (without shutting down the game), and start Lisp.

First, let's change packages, enter the directory where the code is, and compile and load the Lisp files. Notice I keep my game on the E: drive, in an odd directory - you should replace that with your own path:

cg-user(14): (in-package common-lisp-user) 
#<The common-lisp-user package>

cl-user(15): :cd e:/neverwinternights/nwn/shadow door/lisp sources
e:\neverwinternights\nwn\shadow door\lisp sources\

cl-user(16): :cl sd-allegro-socket-interface.lisp
;;; Compiling file sd-allegro-socket-interface.lisp
; While compiling (:top-level-form "sd-allegro-socket-interface.lisp" 464):
;;; Writing fasl file sd-allegro-socket-interface.fasl
;;; Fasl write complete
; Fast loading
;    e:\neverwinternights\nwn\shadow door\lisp sources\sd-allegro-socket-interface.fasl

cl-user(17): :cl sd-utilities.lisp
;;; Compiling file sd-utilities.lisp 
;;; Writing fasl file sd-utilities.fasl 
;;; Fasl write complete 
; Fast loading e:\neverwinternights\nwn\shadow door\lisp sources\sd-utilities.fasl

The game server address is stored in the **sd variable, which is a connection struct, under the server-name slot. It's set to "localhost" by default, but we can change it to a different server by saying:

cl-user(20): (setf (connection-server-name **sd) "129.105.100.165")
"129.105.100.165"

Now we let Lisp connect to the server, and send a simple command to say something and play an animation:

cl-user(24): (sd-connect)
#<multivalent stream socket connected from localhost/3039 
to localhost/1890 @#x21016302>
cl-user(25): (sd-send-speak "greetings")
nil
cl-user(26): (progn 
               (sleep 12) 
               (sd-send-animate animation-oneshot-salute) 
               (sleep 1.0)
               (sd-send-speak "how are you sir?"))
nil

We quickly switch back to the game, just in time to observe the salute and the question being performed. Inside the game we type a quick reply in the chat window, and switch back to Lisp. Let's retrieve the player utterance observed in the game, save it in a variable and examine it, then have the NPC approach some object in the game and say another string:

cl-user(27): (setf incoming (sd-read-sexp))
("speech" "rob" "says" "i'm fine, thank you")

cl-user(28): (sd-speech? incoming)
t
cl-user(29): (sd-speech-text incoming)
"i'm fine, thank you"
cl-user(30): (sd-speech-speaker incoming)
"rob"

cl-user(31): (sd-send-moveto "chest1")
nil
cl-user(32): (sd-send-speak "can you open this chest?")
nil

Switching back to the game, we can see the agent moved closer to the object and said the string. We now attack the agent, and then switch to Lisp, to retrieve observation about the attack, and attempt a counter-attack:

cl-user(36): (setf incoming (sd-read-sexp))
("attacked-by" "rob")
cl-user(37): (sd-attack? incoming)
t
cl-user(38): (sd-send-attack (sd-attacker-name incoming))
nil

At which point the agent, being much weaker, dies. We disconnect from the game.

cl-user(39): (sd-disconnect)
nil

And there we are.

 

System Limitations / To Do List

This being the first release, the system has some limitations which we hope to rectify soon:

  1. The interface only supports one NPC.
  2. The interface has a one-command buffer that gets polled once a second; sending more than one command per second will result in dropped commands.
  3. Each command has to arrive at the socket in one TCP packet.
  4. Connections are not authenticated; anyone can connect to an open interface port.
  5. Only a handful of commands and observations are supported.

 

4. Under the Hood

Ok, here come the nasty architectural bits.

Use of Shadow Door is a matter of three different parts playing together: an external process that sends commands and receives observations through a socket using the Shadow Door Protocol, a server extension that translates between the socket and the ongoing game, and a set of NWN scripts that execute commands and send back observations.

When you run NWNX2 Shadow Door, it starts up a new game server, but also opens a socket on port 1890 and listens for incoming commands. The diagram is roughly as follows:

 
  LISP    

< =============== >
Shadow Door Protocol
over TCP port 1890

Game Server
running the Shadow Door Library

Socket Interface <=> NPC running
Shadow Door scripts
 

 

Shadow Door Protocol

The external process sends NPC the commands through the socket interface, and the NPC sends back observations. They are both serialized strings with one or more tokens separated by # signs, for example: "speak!#waves#hi rob" or "attacked-by#rob". The more general syntax is:

" <command-or-observation>[#[<parameter>]]+ "

Omitted parameter is equivalent to an empty string. Currently supported commands are:

Currently supported observations are:

The socket interface requires the server to be started using NWNX2 Shadow Door. NWNX is a very clever injector for our custom DLL - for details on what it does, see its documentation.

At the moment, Shadow Door is limited by expecting to receive the entire command in a single TCP packet. Until that gets fixed, attempting to communicate with the server using a telnet window or another multi-packet mechanism will have undefined results.

 

Shadow Door Scripts

These are very simple scripts that:

Shadow Door functionality in the scripts is accomplished by the following functions provided in shadowdoor.erf:

 

5. Extending Shadow Door

 

Adding New Commands

Commands can be added by extending the sd_on_heartbeat script.

First, create a void handler function, akin to do_move(), do_animate(), and so on. It should call SD_GetNextToken() repeatedly to retrieve optional arguments, then perform the action and exit. Finally, add the handler function to do_things().

 

Adding New Observations

Due to the event-driven nature of NWScript, there are limits on the kinds of observations that can be performed. In the creature's Properties window select the Scripts tab, then pick and edit the appropriate entry.

A script file for an event should begin with the line:

#include "sd_include"

which includes the necessary Shadow Door functions. Subsequently, the main() function should assemble the serialized observation string, and call SD_Send() to ship it.